use-ai-audio-noise-guard.ts · 분석일: 2025-05-20 · 담당: charles-na
AI 아바타(핑퐁이)가 발화하는 도중, 네트워크가 일시적으로 불안정해지는 순간에 기계음 감지 로직이 오탐(False Positive)을 발생시켜 세션이 중단되는 현상이 보고됨.
CV < 0.35 AND ZCR ≥ 0.04
CV(변동계수)가 낮고 ZCR(영점교차율)이 적당히 높으면 기계음으로 판정. 정상 음성의 CV ≈ 0.85, 기계음의 CV ≈ 0.27.
WebRTC 스택(Chrome)이 패킷 손실 구간을 감추기 위해 직전 오디오 프레임을 반복하는 기법. 수신자에게는 짧은 침묵 대신 균일한 파형이 들린다.
onNoiseDetected() 발화 → 세션 강제 종료.
| 신호 유형 | CV (변동계수) | ZCR (영점교차율) | RMS 패턴 | 기계음 판정 |
|---|---|---|---|---|
| 정상 음성 | ~0.85 (높음) | ~0.08–0.15 | 불규칙 변동 | 통과 안 함 |
| 기계음 TTS | ~0.27 (낮음) | ≥ 0.04 | 주기적·안정 | 감지됨 |
| PLC 반복 프레임 | ≈ 0 (매우 낮음) | 원래 음성과 동일 | 완전 균일(복사) | 오탐! (기존) |
| 무음 | ∞ (mean≈0) | ≈ 0 | RMS < -50dB | RMS 필터로 제외 |
네트워크 불안정 감지 시 기계음 판정 자체를 끄는 방법.
WebRTC getStats()로 PLC 활성 여부를 직접 측정하여, PLC 활성 구간에서 나온 기계음 후보 프레임만 제외하는 방법.
concealedSamples 등)를 직접 측정하므로 정확.
신호 처리 계층을 수정하지 않고 게이팅만 추가.
PLC 프레임 자체를 오디오 신호로 구분하는 것은 매우 어렵다.
직전 프레임 복사본이므로 합법적인 음성 반복과 구별되지 않는다.
반면 WebRTC inbound-rtp 통계는 브라우저가
내부적으로 PLC를 적용한 샘플 수(concealedSamples)를
직접 카운팅하므로 100% 신뢰도로 PLC 활성 여부를 알 수 있다.
// PLC 감지용 WebRTC stats 샘플링 주기 const PLC_STATS_INTERVAL_MS = 500; // PLC active 상태가 stats 없이도 유지되는 시간 const PLC_STATE_TTL_MS = 1_500; // 500ms 구간 내 concealed 비율이 이 값 이상이면 PLC 활성 const PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD = 0.03; // concealmentEvents 증가량이 이 값 이상이면 PLC 활성 const PLC_CONCEALMENT_EVENTS_THRESHOLD = 2; // packetsLost 증가량이 이 값 이상이면 PLC 활성 const PLC_PACKETS_LOST_THRESHOLD = 3; // jitter가 80ms 이상이면 PLC 위험 구간으로 간주 const PLC_JITTER_THRESHOLD_MS = 80;
export interface AudioInboundStats { timestamp: number; concealedSamples?: number; concealmentEvents?: number; totalSamplesReceived?: number; silentConcealedSamples?: number; packetsLost?: number; packetsReceived?: number; jitter?: number; }
interface PlcState { active: boolean; updatedAt: number; concealedSampleRatio: number | null; concealmentEventsDelta: number; packetsLostDelta: number; jitterMs: number | null; }
500ms마다 getAudioInboundStats()를 호출해 inbound-rtp 통계를 읽는다.
이전 샘플 대비 델타값을 계산하여 PLC 활성 여부를 판정한다.
const concealedSamplesDelta = stats.concealedSamples - prev.concealedSamples; const totalSamplesReceivedDelta = stats.totalSamplesReceived - prev.totalSamplesReceived; const concealedSampleRatio = concealedSamplesDelta / totalSamplesReceivedDelta; const active = concealedSampleRatio >= 0.03 // 수신 샘플의 3% 이상이 PLC || concealmentEventsDelta >= 2 // 500ms 안에 PLC 이벤트 2회 이상 || packetsLostDelta >= 3 // 500ms 안에 패킷 3개 이상 손실 || jitterMs >= 80; // 지터 80ms 이상
PLC_STATE_TTL_MS = 1500ms — PLC가 감지된 후 stats가 정상화되더라도
1.5초간 active 상태를 유지한다. 링 버퍼(700ms) + 재검증(200ms) 전체 윈도우를 커버하기 위함.
isNoiseFrame은 CV/ZCR 조건만 본다.
isEffectiveNoiseFrame은 여기에 PLC 비활성 조건을 추가로 AND한다.
PLC가 활성화된 동안은 기계음 후보 프레임이 카운트에 포함되지 않는다.
if (isNoiseFrame && isPlcActive) { logger.info("Noise candidate frame suppressed during PLC-active window", { cv: cv.toFixed(4), rmsDb: rmsDb.toFixed(1), zcr: zcr.toFixed(4), concealedSampleRatio: ..., concealmentEventsDelta: plcState.concealmentEventsDelta, packetsLostDelta: plcState.packetsLostDelta, jitterMs: ..., }); }
기계음 후보이지만 PLC로 억제된 프레임은 INFO 레벨로 기록되어
실제 억제가 동작하는지 추적 가능.
pc.getStats()는 ICE 재연결(ICE restart) 중 예외를 던질 수 있다.
ICE 재연결은 정확히 네트워크가 불안정한 순간에 발생한다.
즉, PLC 보호가 가장 필요한 시점에 getStats가 실패하고,
catch가 active = false를 덮어써서 PLC 보호를 해제하는 역설이 발생한다.
PLC_STATE_TTL_MS(1500ms)가 만료되기 전까지는 active가 자연스럽게 유지되며,
이후 TTL 만료로 active = false가 된다.
pc.getStats()const getAiAudioInboundStats = useCallback(async () => { const pc = pcRef.current; if (!pc || pc.connectionState === "closed") return null; const statsReport = await pc.getStats(); let result: AudioInboundStats | null = null; statsReport.forEach((report) => { if (report.type === "inbound-rtp" && report.kind === "audio") { result = { timestamp: report.timestamp, concealedSamples: report.concealedSamples, concealmentEvents: report.concealmentEvents, totalSamplesReceived: report.totalSamplesReceived, silentConcealedSamples:report.silentConcealedSamples, packetsLost: report.packetsLost, packetsReceived: report.packetsReceived, jitter: report.jitter, }; } }); return result; }, []);
useAiAudioNoiseGuard({ aiAudioStream: noiseTestHarness.syntheticNoiseStream ?? aiSession.aiAudioStream, isPeerTalking: noiseTestHarness.isNoiseSimulating || aiSession.isPeerTalking, enabled: noiseGuardEnabled, getAudioInboundStats: noiseTestHarness.syntheticNoiseStream ? undefined // 테스트 모드: PLC 보호 OFF (합성 신호라 실제 stats 없음) : aiSession.getAiAudioInboundStats, // 프로덕션: 실제 WebRTC stats onNoiseDetected: () => { /* ... */ }, });
syntheticNoiseStream)은 1kHz 사인파를 직접 생성하므로
WebRTC stats가 존재하지 않는다. undefined를 전달하면
noise guard 내부에서 plcStateRef.active = false로 유지되어
기계음 감지가 의도대로 동작한다.
| 상수명 | 값 | 의미 | 비고 |
|---|---|---|---|
PLC_STATS_INTERVAL_MS |
500 | WebRTC stats 폴링 주기 | 너무 짧으면 성능 부담 |
PLC_STATE_TTL_MS |
1500 | PLC 활성 상태 유지 시간 | 링 버퍼(700) + 재검증(200) + 여유 |
PLC_CONCEALED_SAMPLE_RATIO_THRESHOLD |
0.03 (3%) | 500ms 내 concealed 샘플 비율 임계치 | 낮을수록 민감 |
PLC_CONCEALMENT_EVENTS_THRESHOLD |
2 | 500ms 내 concealment 이벤트 횟수 | 1로 낮추면 더 민감 |
PLC_PACKETS_LOST_THRESHOLD |
3 | 500ms 내 패킷 손실 수 | 2로 낮추면 더 보수적 |
PLC_JITTER_THRESHOLD_MS |
80ms | 지터 임계치 | 40ms로 낮추면 더 이른 감지 |
PLC_JITTER_THRESHOLD_MS = 80ms는 보수적이다. 체감 품질 저하는 보통 40ms부터 시작되므로 40ms로 낮추면 더 이른 시점에 PLC를 감지할 수 있다.
PLC_PACKETS_LOST_THRESHOLD = 3도 절대 카운트라 세션 길이에 영향 받을 수 있다. 2로 낮추는 것도 검토 가능.
localhost:3000/app/guest/ 게스트 페이지 접속 후 수업 시작window.__noiseTest?.start() 실행Mechanical noise confirmed after re-verificationNoise candidate frame suppressed during PLC-active window 로그 확인PLC active 로그 순서 확인:INFO Noise candidate frame suppressed during PLC-active window { cv: "0.0012", concealmentEventsDelta: 4, ... } ... (패킷 복구 후 1.5초 TTL 만료) ... INFO Noise re-verification failed, resuming normal analysis (← 재검증 중 PLC 억제로 기계음 카운트 부족 → 오탐 회피)
| 파일 | 변경 내용 |
|---|---|
apps/web/hooks/use-ai-audio-noise-guard.ts |
PLC 상수 및 인터페이스 추가 · samplePlcState() 콜백 구현 ·
isEffectiveNoiseFrame 게이팅 적용 (링 버퍼 + 재검증) ·
stats 폴링 useEffect 추가 · catch 블록 버그 수정
|
apps/web/entities/guest-session/model/use-ai-session.ts |
getAiAudioInboundStats 콜백 추가 —
RTCPeerConnection.getStats()에서 inbound-rtp audio 리포트 추출
|
apps/web/entities/guest-page-session/model/use-guest-page-session.ts |
useAiAudioNoiseGuard에 getAudioInboundStats prop 연결 ·
테스트 모드에서는 undefined 전달 (PLC 보호 OFF)
|
Generated: 2025-05-20 · PPI dev analysis